review-pr: Avoid OIDC for direct API key reviews - #40
Conversation
Direct API-key callers do not need AWS-backed credential setup, but the reusable workflow unconditionally requested id-token: write and ran setup-credentials. Organizations that disable OIDC for reusable workflows were blocked at the permission-validation step. Drop the top-level and per-job permissions blocks so the workflow inherits from the caller. When a model key secret is supplied, skip the OIDC credential fetch and authorize using the caller's GITHUB_TOKEN by checking repository write permission instead of Docker org membership. setup-credentials/action.yml gains a fetch-credentials input (default true). When false, DOCKER_AGENT_ACTION_ROOT is exported and Node.js is set up, but the OIDC exchange is skipped. The review job always runs setup-credentials (for DOCKER_AGENT_ACTION_ROOT and Node) and passes fetch-credentials dynamically, so direct-key callers pay no OIDC cost. The reply-to-feedback and reply-to-mention jobs skip setup-credentials entirely when a direct key is present — they use bash gh-api calls and uses: actions, not dist/ scripts, so neither DOCKER_AGENT_ACTION_ROOT nor an explicit Node install is needed. src/check-org-membership adds checkRepositoryWritePermission (write/maintain/admin collaborator check) and an isAuthorizedUser helper that dispatches to org membership (when ORG_MEMBERSHIP_TOKEN is set) or the repo permission check (when it is not). The CLI entry point now accepts GITHUB_TOKEN as a fallback for GITHUB_APP_TOKEN. The mention-reply handler and the reply-to-feedback auth step receive the same treatment: org-membership-token is now optional; when absent, authorization falls back to checkRepositoryWritePermission. The HAS_DIRECT_API_KEY guard that silently dis Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
) ## Summary The review agent could consume up to 2 x 2700 s (plus up to ~9 min of feedback-processing retries) inside a job capped at 50 minutes. A first attempt timing out at 45 min launched a retry that GitHub always killed at the job ceiling: wasted retry, cancelled run, and the composite's `always()` steps (lock release, summary, reactions) never ran, leaving a residual lock and no feedback on the PR. A retry after a partial failure could also post a duplicate review. ## Changes ### Runner: two new root-action inputs (`src/main/exec.ts`, `action.yml`) | Input | Behavior | | --- | --- | | `total-timeout` (seconds, 0 = unlimited) | Wall-clock budget across all attempts and retry delays. Each attempt is capped to the remaining budget; a retry only starts with at least 60 s left. The runner ends the agent step itself, so a job-level `timeout-minutes` kill never fires and cleanup steps stay alive. | | `no-retry-pattern` (regex) | Tested against the verbose log after a failed attempt. On match, remaining retries are skipped without consuming a retry budget. Fail-open: invalid regex or unreadable log just disables the guard. | ### Budget rewiring (`review-pr/action.yml`, `review-pr.yml`) | Step | Worst case before | Worst case after | | --- | --- | --- | | Feedback processing | 180 s x 3 attempts + delays (~9.4 min) | `total-timeout: 300` (5 min hard) | | Run PR Review | 2700 s x 2 via `max-retries` + `retry-on-timeout` (90+ min) | `total-timeout: 2700` (45 min hard) | | Job ceiling | 50 min, killed mid-composite | 60 min safety net that should never fire | ``` timeout-minutes: 60 (safety net) └─ review step, total-timeout 2700 s (runner-enforced) ├─ attempt 1: capped to min(2700, remaining budget) └─ retry: only if >= 60 s remain AND log does not match no-retry-pattern ``` - `retry-on-timeout: 1` removed: a second 45 min pass can never fit the budget; timeouts are already surfaced on the PR for a manual re-request. - `no-retry-pattern: pullrequestreview-[0-9]+` (the same marker the "Post clean summary" step greps to detect a posted review): no duplicate review on retry. - Stale comments fixed: "now 1800 s" (`review-pr/action.yml`), "35-min job budget" (`review-pr/action.yml`), "1800s (30 min)" (`AGENTS.md`). ## Issue expectations mapping | Issue point | Handled by | | --- | --- | | 2 x 2700 s agent in a 50 min job | `total-timeout: 2700` on the review step, `retry-on-timeout` removed | | Retry always killed by GitHub at 50 min | Runner-enforced budget ends before the job ceiling; ceiling raised to 60 min as safety net | | Composite `always()` steps skipped (residual lock, no PR feedback) | Job-level kill can no longer interrupt the composite | | Stale comments (1800 s, 35-min budget) | Updated in `review-pr/action.yml`, `AGENTS.md` | | Bonus: duplicate review posted by retry | `no-retry-pattern` guard | | Must not hinder other open PRs | See below | ## Impact on other open PRs and consumers - New inputs default to inert values (0 / empty): every other root-action consumer keeps identical behavior. - `review-pr/action.yml` references the root action by pinned SHA (v2.0.2): the new inputs stay inactive until the next release re-pins the chain (release.yml 3-pass), so no mixed state is possible. - Per-PR `concurrency` group and `cancel-in-progress: false` untouched: one PR's budget never affects another PR's review. - Merge order against open PRs #40, #42, #43, #47 verified by local merge simulation: only #40 produces a single adjacency conflict in `review-pr.yml` (its `env:` block replaces the `permissions:` block right below the `timeout-minutes` line); resolution keeps both sides verbatim, whichever lands second. ## Validation | Check | Result | | --- | --- | | `pnpm build` | pass | | `pnpm test` (753 tests, 8 new for total-timeout / no-retry-pattern) | pass | | `pnpm test:integration` | pass | | `pnpm lint` (Biome + tsc + actionlint) | pass | | `tests/test-job-summary.sh`, `tests/test-output-extraction.sh` | pass | --------- Signed-off-by: Sayt0 <138035894+Sayt-0@users.noreply.github.com> Co-authored-by: Derek Misler <derek.misler@docker.com>
aheritier
left a comment
There was a problem hiding this comment.
Review — head b78047f
CI: ✅ Lint, Typecheck, Unit Tests, Integration Tests all success. Note these results are for the current head, which no longer merges cleanly with main, so they don't cover the post-rebase state.
Mergeable: ❌ CONFLICTING.
The direction is right and the TypeScript side is genuinely well covered. Three things block merging.
Blocking
1. [blocking] Conflicts with main. git merge-tree origin/main pull/40/head → CONFLICT (content) in .github/workflows/review-pr.yml and review-pr/README.md. Merge-base is ddb0139; main has since moved internal pins to v2.0.4, added the Check trigger context exists step, timeout-minutes: 60, and actions: write.
2. [blocking] The actions: write requirement and its docs get lost / contradicted. main review-pr.yml:293 has actions: write # cache delete for review-lock release cleanup; feedback artifact list/delete, documented as required since v2.0.3 in README.md:238,242 and review-pr/README.md:10,40,121. This PR removes every permissions: block, and its review-pr/README.md (head lines 37, 115, 130) tells callers actions: read. On merge, README.md auto-merges (keeping "actions: write required since v2.0.3") while review-pr/README.md conflicts — so the resolved docs will contradict each other and the workflow declares nothing at all. Two consequences:
-
(a) A caller following the new text grants
actions: read; review-lock cache cleanup and feedback-artifact list/delete then 403 silently at runtime, where previously that misconfiguration failed loudly at startup validation. -
(b) The caller-permissions release safeguard (issue #72) goes inert. Demonstrated with the repo's own extractor (
computeCallerRequirement(parseWorkflowPermissions(...))):main→{"contents":"read","pull-requests":"write","issues":"write","id-token":"write","actions":"write","checks":"write"}- this PR →
{}
so
diffCallerRequirementscan never flag a future increase forreview-pr.ymlagain.
Dropping the block is the only way to make id-token optional (a called workflow can't request permissions conditionally), so the fix isn't to restore it — but the docs must keep stating actions: write and what degrades without it, say that id-token: write is only for the AWS path, and the safeguard needs a declared requirement source it can still read.
3. [blocking] Internal uses: pins point at a PR-branch commit with a non-version comment. review-pr.yml:296, 571, 805, 873 → @5e46aee485641c251fdaeb9a05bd9977c711def8 # direct API key OIDC fix. 5e46aee is this PR's own first commit and is not on main; main pins the same action at b08367e… # v2.0.4 at 6 sites, and no workflow/script in this repo rewrites internal pins. As-is this ships a reusable workflow resolving setup-credentials and mention-reply from an unreleased branch commit, mixed with v2.0.2/v2.0.4 pins in the same file. Repoint to the released SHA + version comment (AGENTS.md, "Versioning & releases").
Should fix
4. [should-fix] The auth-model downgrade is inferred from an empty env var rather than stated. src/check-org-membership/index.ts:294 dispatches on inputs.orgToken && inputs.org, and main() no longer fails when ORG_MEMBERSHIP_TOKEN is absent (the if (!orgToken) core.setFailed(...) guard is gone, index.ts:346). setup-credentials/action.yml only verifies GITHUB_APP_TOKEN, never ORG_MEMBERSHIP_TOKEN. So an OIDC-path run whose secret payload loses ORG_MEMBERSHIP_TOKEN (or whose ORG env is unset) silently switches from "Docker org member" to "has repo write" instead of failing closed. Pass the intent explicitly (an AUTH_MODE mirroring fetch-credentials) and keep the hard failure when org mode is expected.
5. [should-fix] A direct key silently wins over OIDC even when the caller wants OIDC. review-pr.yml:174 derives HAS_DIRECT_API_KEY from any non-empty model-key secret, and :296 then passes fetch-credentials: false. A caller granting id-token: write that also inherits, say, an org-level ANTHROPIC_API_KEY gets no GITHUB_APP_TOKEN — so the bot posts as github-actions[bot] instead of docker-agent (:409 github-token: ${{ env.GITHUB_APP_TOKEN || github.token }}) — plus the weaker authorization path. That contradicts the wording this PR adds ("Preferred: OIDC authentication to AWS Secrets Manager", review-pr/README.md:114). An explicit input (credentials: auto|aws|direct) or OIDC-wins precedence would be predictable.
6. [should-fix] New authorization logic added as inline bash, duplicating the new TS function. review-pr.yml:604-611 reimplements checkRepositoryWritePermission (src/check-org-membership/index.ts:120-138). AGENTS.md ("TypeScript / src rules") requires new composite-action logic in src/ with Vitest tests; the bash copy is untested and its admin|maintain|write allowlist must be kept in sync by hand. It also behaves differently: ... || echo "none" (:604) collapses any API error into "not authorized" and tells the user "does not have write access", while the TS path rethrows non-404s and fails loudly. Invoke dist/check-org-membership.js, as the review job already does at :321.
7. [should-fix] The new path has no end-to-end coverage. self-review-pr.yml passes no secrets: to the reusable workflow, so HAS_DIRECT_API_KEY is always false in dogfooding, and test-e2e-reviewer.yml is dispatch-only — nothing exercises the YAML gating. The unit tests cover the TS branch well (checkRepositoryWritePermission ×3, evaluateMembership fallback ×3, mention-reply fallback ×3); please add a dispatch E2E scenario, or a tests/*.sh for the new bash branch per repo convention.
8. [should-fix] Docs currency: AGENTS.md is untouched. It still describes setup-credentials/ as an unconditional OIDC fetch and check-org-membership/ as org-membership-only; the new fetch-credentials input and the repo-write authorization tier are absent from both the layout entries and the security-first authorization tiers.
9. [should-fix] The PR description is truncated mid-sentence ("The HAS_DIRECT_API_KEY guard that silently dis"), so the rationale for a security-relevant guard is missing from the durable record. The only linked issue is a Slack permalink — please add a repo-qualified GitHub issue for provenance.
Optional
10. [optional] review-pr/README.md:121-122 — "…you may omit id-token: write:" ends with a colon introducing the YAML block, but an unrelated "actions: read is always required…" sentence is spliced in between. Reorder so the colon leads into the example.
Verified as safe — no action needed
- Every
GITHUB_APP_TOKENconsumer now has a|| github.tokenfallback (:344, 409, 740, 816, 878, 900, 907); no step is left with an empty token when the OIDC fetch is skipped. - Skipping
setup-credentialsin the reply jobs is safe:$DOCKER_AGENT_ACTION_ROOTis used only in the review job (:321, :346), which always runs the action, and the mention-reply handler is anode24JS action needing nosetup-node. checkRepositoryWritePermissionfails closed (404 → false, other statuses rethrown); public-repo non-collaborators returnread, not 404, and are correctly rejected.??→||fororgTokeninsrc/mention-reply/index.ts:264correctly stops an empty env var from winning.- actionlint accepts the new
env-in-with/ifandsecrets-in-job-envusages (only pre-existing SC2086 info remains).
Recommendation
Not mergeable as-is. Rebase onto main, settle the actions: write / docs question (2), and repoint the pins (3). Happy to re-review after that.
Related Issues
Summary
Direct API-key callers do not need AWS-backed credential setup, but the
reusable workflow unconditionally requested id-token: write and ran
setup-credentials. Organizations that disable OIDC for reusable
workflows were blocked at the permission-validation step.
Drop the top-level and per-job permissions blocks so the workflow
inherits from the caller. When a model key secret is supplied, skip the
OIDC credential fetch and authorize using the caller's GITHUB_TOKEN by
checking repository write permission instead of Docker org membership.
setup-credentials/action.yml gains a fetch-credentials input (default
true). When false, DOCKER_AGENT_ACTION_ROOT is exported and Node.js is
set up, but the OIDC exchange is skipped. The review job always runs
setup-credentials (for DOCKER_AGENT_ACTION_ROOT and Node) and passes
fetch-credentials dynamically, so direct-key callers pay no OIDC cost.
The reply-to-feedback and reply-to-mention jobs skip setup-credentials
entirely when a direct key is present — they use bash gh-api calls and
uses: actions, not dist/ scripts, so neither DOCKER_AGENT_ACTION_ROOT
nor an explicit Node install is needed.
src/check-org-membership adds checkRepositoryWritePermission
(write/maintain/admin collaborator check) and an isAuthorizedUser helper
that dispatches to org membership (when ORG_MEMBERSHIP_TOKEN is set) or
the repo permission check (when it is not). The CLI entry point now
accepts GITHUB_TOKEN as a fallback for GITHUB_APP_TOKEN.
The mention-reply handler and the reply-to-feedback auth step receive
the same treatment: org-membership-token is now optional; when absent,
authorization falls back to checkRepositoryWritePermission. The
HAS_DIRECT_API_KEY guard that silently dis